第四章 程序包结构与状态

本章将通过把您从 使用 R 包中获得的隐性知识转换为 创建和修改它们所需的显式知识,从而开始开发程序包。您将了解程序包的各种状态,以及它和库(library)之间的区别(以及为什么要关心二者的区别)。

4.1 程序包状态

创建或修改程序包时,需要在它的“源代码”或“源文件”上进行。您能够以源代码的形式与正在开发的程序包进行交互。当然,这并不是你日常使用中最熟悉的程序包的形式。如果您了解 R 程序包可能处于的五种状态,那么程序包开发的工作流将变得更有意义:

  • 源代码(source)
  • 捆绑的(bundled)
  • 二进制文件(binary)
  • 已安装的(installed)
  • 载入内存中的(in-memory)

您已经知道一些将程序包转入这些状态的函数。例如,install.packages()devtools::install_github() 将程序包从源代码(source)、已绑定的(bundled)或二进制文件(binary)状态转移到已安装(installed)状态。library() 函数的作用是:将已安装的程序包加载到内存中,以便可以马上直接使用。

4.2 源码包(Source Package)

一个源代码程序包就是一个有着特定结构的文件目录。它包含特定的组件,例如一个 DESCRIPTION 文件、包含 .R 文件的 R/ 目录等。本书余下的大部分章节都致力于详细说明这些组成部分。

如果您刚刚接触 R 程序包的开发,那么您可能从未见过源代码形式的程序包!您的计算机上可能甚至没有任何源程序包。以源代码形式查看程序包的最简单的方法是在 web 上浏览其代码。

许多 R 程序包是在 GitHub(或者 GitLab 以及类似的平台)上的公开库(open)中开发的。最好的方案是访问程序包的 CRAN 主页(landing page),例如:

并且其中一个网页链接(URLs)链接到公共托管服务上的存储库,例如:

即使程序包是在公共存储库中开发的,一些维护人员还是忘记了列出这个网页链接(URL),但是您仍然可以通过搜索来发现它。

即使程序包不是在公共平台上开发的,也可以在 METACRAN 维护的非官方只读镜像中访问其源代码。示例:

请注意,这与探索程序包真正的开发环境不同,因为这里的源代码及其演变过程只是对程序包的 CRAN 发行版本进行逆向工程的结果。它提供了对程序包及其开发历史的审查视图,但根据定义,源代码及其历史包含了所有程序包开发的必需的内容。意译,有待审查

4.3 捆绑包(Bundled package)

捆绑的程序包是被压缩成单个文件的程序包。按照惯例(该惯例来自 Linux),R 中的捆绑程序包使用 .tar.gz 扩展名,并且有时被称为“源码压缩包”。这意味着多个文件已经被打包为一个文件(.tar)并使用 gzip(.gz)进行压缩。虽然捆绑程序包本身并不那么有用,但它是源码包和已安装包之间平台无关的、便于传输的中间媒介。

在从本地开发的程序包中生成捆绑程序包这种罕见的情况下,请使用 devtools::build()。在幕后,它会调用 pkgbuild::build() 并最终调用 R CMD build,这些会在 Writing R ExtensionsBuilding package tarballs 部分中进一步阐述。

这应该会提醒您,捆绑程序包或“源码压缩包”(source tarball)不仅仅是对源文件进行 tar 打包存档,然后使用 gzip 压缩的结果。按照惯例,在 R 世界中,在制作 .tar.gz 文件时还要执行一些操作。这就是我们选择将其称为捆绑程序包的原因理解“捆绑”一词的重要语段

每一个 CRAN 程序包都以捆绑程序包的形式提供,可以通过个人登录界面(individual landing pages)的”Packages Source”字段内容获取。继续我们上面的示例,您可以下载 forcats_0.4.0.tar.gzreadxl_1.3.1.tar.gz 的捆绑包。(或者任何当前的版本)。您可以在 shell(而不是 R 控制台)中进行解压缩:

tar xvf forcats_0.4.0.tar.gz

如果您解压缩一个捆绑包,您将看到它看起来几乎与源码包相同。下图总结了 devtools 的源码版本、捆绑版本和二进制版本的顶级目录中出现的文件。

TODO: Remake this figure https://github.com/hadley/r-pkgs/issues/587.

_images/package-files.png
FIGURE 4.1: Side-by-side comparison of source, bundled, and binary package.
图片 4.1:源码包、捆绑包和二进制包的并排比较

源码包和未压缩的捆绑包之间的主要区别为:

  • 已经生成了 Vignettes,因此以已渲染的输出(如 HTML)出现在 inst/doc/ 目录下,并且 Vignette 索引出现在 build/ 目录中,通常还有一个 PDF 的程序包手册。
  • 本地源码包可能包含用于在开发期间节省时间的临时文件,如 src/ 中的编译文件。这些文件从来没有在捆绑包中找到过。
  • .Rbuildignore 中列出的任何文件都不包含在捆绑包中。这些文件通常有助于您的开发过程,但应该从分发式原意“分布式”的产品中排除。

4.3.1 .Rbuildignore

您不需要非常频繁地考虑 .tar.gz 文件形式的程序包的确切结构,但您确实需要了解 .Rbuildignore 文件。它决定了源码包中的哪些文件可以进入后面的工作流。

.Rbuildignore 的每一行都是与 Perl 兼容的正则表达式,它与源码包中每个文件的路径匹配,而不考虑大小写。[1] 如果与正则表达式匹配,则排除该文件或目录。注意,有一些默认排除项由 R 本身执行,主要与经典的版本控制系统和编辑器(如 SVN、Git 和 Emacs)有关。

要排除特定的文件或目录(最常见的使用示例),您必须锚定(anchor)正则表达式。例如,要排除名为”notes”的目录,请使用 ^notes$。正则表达式 notes 将匹配任何包含 notes 的文件名,例如 R/notes.Rman/important-notes.Rdata/endnotes.Rdata 等。排除特定文件或目录的最安全方法是使用 usethis::use_build_ignore("notes"),它将为您执行转义。

.Rbuildignore 是解决让您更便利地开发的操作意译与 CRAN 提交和分发的要求之间一些紧张关系的一种方法。即使您不打算在 CRAN 上发布,遵循这些约定能让您最好地使用 R 的内置工具来检查和安装程序包。受影响的文件分为两个半重叠的类别:

  • 帮助您以编程方式生成程序包内容的文件。例如:

    • 使用 README.Rmd 生成信息和当前的 README.md
    • 存储 .R 脚本以创建和更新内部的或导出的数据。
  • 驱动程序包开发、检查和产生文档的文件,不在 CRAN 的范围内。例如:

    • 与 RStudio IDE 相关的文件
    • 使用 pkddown package 生成的网站。
    • 与持续集成/部署和监视测试覆盖范围相关的配置文件。

以下是 tidyverse 中程序包的 .Rbuildignore 文件中典型条目的非完整列表:

^.*\.Rproj$         # Designates the directory as an RStudio Project
^\.Rproj\.user$     # Used by RStudio for temporary files
^README\.Rmd$       # An Rmd file used to generate README.md
^LICENSE\.md$       # Full text of the license
^cran-comments\.md$ # Comments for CRAN submission
^\.travis\.yml$     # Used by Travis-CI for continuous integration testing
^data-raw$          # Code used to create data included in the package
^pkgdown$           # Resources used for the package website
^_pkgdown\.yml$     # Configuration info for the package website
^\.github$          # Contributing guidelines, CoC, issue templates, etc.

请注意,上面的注释不能出现在实际的 .Rbuildignore 文件中。此处包含这些注释只是为了演示。

我们会在需要的时候提到何时需要向 .Rbuildignore 中添加排除项意译。请记住,usethis::use_build_ignore() 是管理此文件的一种有吸引力的方法。

4.4 二进制包(Binary package)

如果要将程序包分发给没有程序包开发工具的 R 用户,则需要提供二进制包。与捆绑包一样意译,二进制包是单个文件。但是与捆绑包不同,二进制包是平台相关的,有两种基本类型:Windows 和 macOS。(Linux 用户通常需要具备从 .tar.gz 文件安装程序包所需要的工具。)

macOS 平台上的二进制包储存为以 .tgz 为后缀的文件,而 Windows 平台的二进制包则以 .zip 为文件后缀。如果你需要制作一个二进制包,则需要在相关的平台上使用 devtools::build(binary = TRUE。在幕后,该函数调用 pkgbuild::build(binary= TRUE) 并且最终调用 R CMD INSTALL --build。这些会在 Writing R ExtensionsBuilding binary packages 部分作进一步阐述。

需要明确的是,二进制包的主要制作者和分发者是 CRAN,而不是个人维护者。如果您的程序包是供公众使用的,那么使其广泛可用的最高效的方法是在 CRAN 上发布它。您提交捆绑包,然后 CRAN 将制作并分发二进制包。

不论是 macOS 或 Windows,还是 R 的当前、先前和(可能的)开发版本,CRAN 通常都能以二进制包形式提供。继续我们上面的例子,您能够下载二进制包,例如:

  • forcats for macOS: forcats_0.4.0.tgz
  • readxl for Windows: readxl_1.3.1.zip

事实上,这是您在调用 isntall.packages() 时通常进行的部分幕后操作。

如果解压缩二进制包,您将看到它的内部结构与源码包或捆绑包有很大不同。图 4.1 包含了二者的比较。以下是一些最显著的区别:

  • R/ 目录中没有 .R 文件,而是有三个文件以有效的文件格式存储着解析的函数。这基本上是加载所有 R 代码,然后用 save() 保存函数的结果。(在这个过程中,这会添加一些额外的元数据,使得过程尽可能地快)。
  • Meta/ 目录中包含许多 .rds 文件。这些文件包含有关包的缓存元数据,如帮助文件所涵盖的主题和 DESCRIPTION 文件的解析版本。(您可以使用 readRDS() 查看这些文件中的内容)。这些文件通过缓存代价高昂的计算使程序包更快地加载。
  • 实际的帮助内容出现在 help/html/``(不再出现在 ``man/)中。
  • 如果 src/ 目录中有任何代码,那么现在将有一个 libs/ 目录,其中包含经过编译的代码。在 Windows 上,有 32 位(i386/)和 64 位(x64/)环境的子目录。
  • 如果 data/ 中有任何对象,则它们现在已转换为更具效率的形式。
  • inst/ 的内容被移动到顶层目录。例如,vignette 文件现在位于 doc/ 中。
  • 一些文件和文件夹已被删除,如 READMEbuild/tests/vignettes/

4.5 已安装的包(Installed package)

已安装的包是已解压缩到程序包库中的二进制包(如 4.7 所述)。下图说明了安装程序包的多种方法。这个图表很复杂!在理想情况下,安装包需要将一组简单的步骤串在一起:source -> bundle,bundle -> binary,binary -> installed。在现实世界中,这个过程并不是这么简单,因为通常有(更快的)快捷方式可用。

_images/installation.png
FIGURE 4.2: Many methods for converting between package states.
图片 4.2:在程序包状态之间转换的许多方法。

内置命令行工具 R CMD INSTALL 支持所有程序包的安装。它可以从源文件、捆绑包(也称为源码压缩包(source tarball))或二进制包安装程序包。有关详细信息,请参阅 R Installation and AdministrationInstalling packages 部分。与 devtools::build() 一样,devtools 提供了一个包装函数 devtools::install(),使该工具在 R 会话(R Session)中可用。

可以理解,大多数用户喜欢 R 会话(R Session)的舒适性,因此直接从 CRAN 安装软件包。内置函数 install.packages() 满足了这一需要。它可以以各种形式下载程序包并安装它,还可以选择程序包依赖项的安装。

devtools 公开了一系列 install_*() 函数,以满足某些超出 install.packages() 范围的需求,或者使现有功能更容易使用。这些功能实际上在 remotes packages 中维护,并由 devtools 重新导出。

library(remotes)

funs <- as.character(lsf.str("package:remotes"))
grep("^install_.+", funs, value = TRUE)
#>  [1] "install_bioc"      "install_bitbucket" "install_cran"
#>  [4] "install_deps"      "install_dev"       "install_git"
#>  [7] "install_github"    "install_gitlab"    "install_local"
#> [10] "install_svn"       "install_url"       "install_version"

install_github() 是这个子系列函数的最佳示例,这些函数可以从非 CRAN 的远程位置下载程序包,并执行安装包所需的任何操作。其余的 devtools/remotes install() 函数旨在使基本工具在技术上更简单或更明确一些,例如 install_version(),它能够安装特定版本的 CRAN 包。

.Rbuildignore 类似,如第 4.3.1 节所述,.Rinstignore 允许您将捆绑包中的文件保留在已安装包之外。然而,与 .Rbuildignore 相反,这个功能相当模糊,而且很少需要这样做。

TODO: Revisit this section later with respect to pak https://pak.r-lib.org.

4.6 内存中的包(In-memory package)

我们终于讲述到了一个每个使用 R 的人都熟悉的命令。

library(usethis)

假设已经安装了 usethis,这个语句将使得里面的所有函数可用,即现在我们可以执行以下操作:

create_package("/path/to/my/coolpackage")

这样, usethis 包已加载到内存中,并且实际上也已附加到搜索路径。在编写脚本时,加载和附加程序包之间的区别并不重要,但在编写程序包时非常重要。在 search path 中您将了解更多关于两者差异的信息,以及为什么它在搜索路径中很重要。

library() 并不是迭代调整和测试正在开发的程序包的好方法,因为它只适用于已安装的包。在第 5.4 节中,您将了解 devtools::load_all() 如何通过允许您将源码包直接加载到内存中来加速开发过程。

4.7 程序包的库(Package libraries)

我们刚刚讨论了 library() 函数,它的名字源于它的作用。当你调用 library(foo) 时,R 会在当前 中查找一个叫做“foo”的已安装包,如果成功了,R 将让 foo 变得可以使用。

在 R 中,一个 就是一个包含了已安装程序包的目录,有点像图书库。不幸的是,在 R 的世界,您将会经常遇到“库”和“包”的混淆用法。例如,delyr 是一个程序包,但是通常有人将其称为一个库。造成这种混乱的原因有几个。首先,R 的术语可以说是与更广泛的编程约定背道而驰的,“库”的通常含义更接近于我们所说的“包”。library() 函数本身的名称可能会强化这一错误的关联。最后,这种词汇错误通常是无害的,因此 R 用户很容易养成错误的习惯,而指出这个错误的人看起来像是令人无法忍受的学究。但底线是:

我们使用 library() 函数加载[2] 一个程序包。

当您参与包开发时,两者之间的区别是重要且有用的。

您的计算机上可以有多个库。事实上,你们中的很多人已经这样做了,尤其是在 Windows 上。可以使用 .libPaths() 查看当前处于活动状态的库。以下是在 Windows上 的外观:

# on Windows
.libPaths()
#> [1] "C:/Users/jenny/Documents/R/win-library/3.6"
#> [2] "C:/Program Files/R/R-3.6.0/library"

lapply(.libPaths(), list.dirs, recursive = FALSE, full.names = FALSE)
#> [[1]]
#>   [1] "abc"           "anytime"       "askpass"       "assertthat"
#>  ...
#> [145] "zeallot"
#>
#> [[2]]
#>  [1] "base"         "boot"         "class"        "cluster"
#>  [5] "codetools"    "compiler"     "datasets"     "foreign"
#>  [9] "graphics"     "grDevices"    "grid"         "KernSmooth"
#> [13] "lattice"      "MASS"         "Matrix"       "methods"
#> [17] "mgcv"         "nlme"         "nnet"         "parallel"
#> [21] "rpart"        "spatial"      "splines"      "stats"
#> [25] "stats4"       "survival"     "tcltk"        "tools"
#> [29] "translations" "utils"

以下是在 macOS 上类似的表现(但您的结果可能会有所不同):

# on macOS
.libPaths()
#> [1] "/Users/jenny/Library/R/3.6/library"
#> [2] "/Library/Frameworks/R.framework/Versions/3.6/Resources/library"

lapply(.libPaths(), list.dirs, recursive = FALSE, full.names = FALSE)
#> [[1]]
#>    [1] "abc"                  "abc.data"             "abind"
#>  ...
#> [1033] "Zelig"                "zip"                  "zoo"
#>
#> [[2]]
#>  [1] "base"         "boot"         "class"        "cluster"
#>  [5] "codetools"    "compiler"     "datasets"     "foreign"
#>  [9] "graphics"     "grDevices"    "grid"         "KernSmooth"
#> [13] "lattice"      "MASS"         "Matrix"       "methods"
#> [17] "mgcv"         "nlme"         "nnet"         "parallel"
#> [21] "rpart"        "spatial"      "splines"      "stats"
#> [25] "stats4"       "survival"     "tcltk"        "tools"
#> [29] "translations" "utils"

在这两种情况下,我们可以看到两个活动库,它们的查询顺序如下:

  1. 用户库
  2. 系统级或全局库

这样的设置是 Windows 上的经典设置,但通常是 macOS 上需要选择的设置。[3] 在这样的设置之下,从 CRAN(或其他地方)安装的或本地开发的附加程序包保存在用户库中。和上面一样,macOS 系统被用作主要的开发机器,这里有很多软件包(大约 1000 个),而 Windows 系统只是偶尔使用,而且要简朴得多。R 附带的基本和推荐程序包的核心集位于系统级库中,这一点在 macOS 和 Windows 上是相同的。这种分离对许多开发人员很有吸引力,例如,在不干扰 base R 的安装的情况下使得清理附加包变得很容易。

如果您在 macOS 上只看到一个库,并不需要紧急更改任何内容。但下次升级 R 时,请考虑创建一个用户级库。默认情况下,R 查找存储在环境变量 R_LIBS_USER 中的路径下的用户库,默认为 ~/Library/R/x.y/library。当您安装 R x.y.z 时,并且在安装任何附加程序包之前,请使用 dir.create("~/Library/R/x.y/library") 设置用户库。现在您将看到像上面一样的库设置。或者,您也可以在其他地方设置一个用户库,并通过在 .Renviron 中设置 R_LIBS_user 环境变量来告诉 R。

这些库的文件路径也清楚地表明它们与特定版本的 R(在编写本文时是 3.6.x)相关联,这也是经典的。这反映并强化了这样一个事实:当您将 R 从 3.5 更新到 3.6,即一个在 **次要(minor)**版本上的更改时,您需要重新安装附加程序包。对于在 **补丁(patch)**版本上的更改,例如从 R 3.6.0 到 3.6.1,通常不需要重新安装附加程序包。

随着 R 的使用变得越来越复杂,开始更加有意地管理程序包库是十分平常的。例如,像 renv(及其前身 packrat)这样的工具可以使管理项目特定库的过程自动化。这对于使数据产品具有可复制性、可移植性和相互隔离性非常重要。程序包开发人员可能会在库的搜索路径前添加一个临时库,其中包含一组特定版本的程序包,以便在不影响其他日常工作的情况下探索前后兼容性问题。反向依赖性检查(Reverse dependency checks)是另一个显式管理库的搜索路径的例子。

以下是按范围和持久性顺序控制哪些库处于活动状态的主要杠杆:

  • 环境变量,如 R_LIBSR_LIBS_USER,它们在启动时被查询。
  • 使用一个或多个文件路径调用 .libPaths()
  • 通过 withr::with_libpaths() 使用临时更改的库搜索路径执行小型的代码段。
  • 单个函数的参数,比如 install.packages(lib =)library(lib.loc =)

最后,需要注意的是, library() 永远不应该在 程序包中 使用。程序包和脚本依赖于不同的机制来声明它们的依赖性,这是您需要在您的心理模型(mental model)和习惯中做出的最大调整之一。我们将在第 11 章全面探讨这个话题。

脚注

[1]要查看应该出现在您的雷达上的文件路径,请在程序包的顶级目录下执行 dir(full.names = TRUE, recursive = TRUE, include.dirs = TRUE, all.files = TRUE)。↩
[2]实际上,library() 加载并附加一个程序包到环境中,但这是另一节(11.2)的主题。↩
[3]有关更多详细信息,请参阅 What They Forgot To Teach You About R 中的 Maintaining R Section